Taeseong Blog

우아한 컴포넌트 만들기: React 컴파운드 패턴

2025-06-06

React

널리 사용되는 UI 라이브러리인 shadcn/ui를 사용하다 보면 아래와 같은 컴포넌트 구조를 자주 볼 수 있습니다.

예를 들어, <Tabs> 컴포넌트의 사용 방식은 다음과 같습니다:

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";

export function Example() {
  return (
    <Tabs defaultValue="account">
      <TabsList>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
      </TabsList>
      <TabsContent value="account">Account content</TabsContent>
      <TabsContent value="password">Password content</TabsContent>
    </Tabs>
  );
}

이처럼 여러 컴포넌트가 긴밀하게 협력하여 동작하는 구조를 컴파운드 컴포넌트 패턴(Compound Component Pattern)이라고 부릅니다.

이번 글에서는 이 패턴을 활용해 아코디언 컴포넌트를 직접 구현해보겠습니다.

컴파운드 컴포넌트 패턴이란?

웹 개발에서는 서로 상태를 공유하며 동작해야 하는 컴포넌트들이 많습니다. 예를 들어, , 아코디언, 드롭다운 등에서는 각 구성 요소들이 서로를 인식하고 함께 작동해야 하죠.

컴파운드 컴포넌트 패턴은 이런 복잡한 상호작용을 우아하게 해결할 수 있는 React 디자인 패턴입니다.

HTML의 <select><option>처럼 자연스럽고 선언적인 API를 만들 수 있다는 것이 큰 장점입니다.

실전 예제: FAQ 아코디언 만들기

FAQ 아코디언 예시


1단계: Context API로 상태 관리하기

Accordion 컴포넌트는 내부 아이템들이 공유할 상태(activeItem)를 관리합니다. 클릭된 아이템을 토글하는 toggleItem 함수와 함께, 이 상태를 Context로 하위에 전달합니다.

import React, { createContext, useContext, useState } from "react";

const AccordionContext = createContext();

function Accordion({ children, defaultValue = null }) {
  const [activeItem, setActiveItem] = useState(defaultValue);

  const toggleItem = (value) => {
    setActiveItem(activeItem === value ? null : value);
  };

  const contextValue = { activeItem, toggleItem };

  return (
    <AccordionContext.Provider value={contextValue}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

2단계: 아코디언 아이템 구성하기

AccordionItem은 트리거(질문)와 콘텐츠(답변)를 감싸는 래퍼입니다. value 속성을 통해 어떤 항목인지 식별합니다.

function AccordionItem({ children, value }) {
  return (
    <div className="accordion-item" data-value={value}>
      {children}
    </div>
  );
}

클릭 시 아코디언을 열고 닫는 버튼입니다. 현재 활성화된 항목인지 판단하여 스타일과 아이콘 회전을 조절합니다.

function AccordionTrigger({ children, value }) {
  const { activeItem, toggleItem } = useContext(AccordionContext);
  const isActive = activeItem === value;

  return (
    <button
      className={`accordion-trigger ${isActive ? "active" : ""}`}
      onClick={() => toggleItem(value)}
      aria-expanded={isActive}
    >
      <span className="accordion-title">{children}</span>
      <span className={`accordion-icon ${isActive ? "rotated" : ""}`}></span>
    </button>
  );
}

AccordionTrigger에 의해 열릴 콘텐츠 영역입니다. 현재 선택된 value와 비교해 표시 여부를 제어합니다.

function AccordionContent({ children, value }) {
  const { activeItem } = useContext(AccordionContext);
  const isActive = activeItem === value;

  return (
    <div className={`accordion-content ${isActive ? "expanded" : "collapsed"}`}>
      <div className="accordion-content-inner">{children}</div>
    </div>
  );
}

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

3단계: 완성된 아코디언 사용 예시

FAQ 페이지처럼 여러 질문과 답변을 보여줄 때 유용합니다. 각각의 질문은 Accordion.Item으로 감싸고, 질문/답변은 Trigger, Content로 구성합니다.

export default function FAQPage() {
  return (
    <div className="faq-container">
      <h1>자주 묻는 질문</h1>

      <Accordion defaultValue="shipping">
        <Accordion.Item value="shipping">
          <Accordion.Trigger value="shipping">
            🚚 배송은 얼마나 걸리나요?
          </Accordion.Trigger>
          <Accordion.Content value="shipping">
            일반 배송은 2-3, 익일배송은 다음날 오후 6시까지 도착합니다. 제주도
            및 도서산간 지역은 1-2일 추가 소요될 수 있습니다.
          </Accordion.Content>
        </Accordion.Item>

        <Accordion.Item value="return">
          <Accordion.Trigger value="return">
            🔄 교환/환불은 어떻게 하나요?
          </Accordion.Trigger>
          <Accordion.Content value="return">
            상품 수령 후 7일 이내 마이페이지에서 교환/환불 신청이 가능합니다.
            단순 변심의 경우 배송비는 고객 부담입니다.
          </Accordion.Content>
        </Accordion.Item>

        <Accordion.Item value="size">
          <Accordion.Trigger value="size">
            📏 사이즈가 맞지 않으면 어떻게 하나요?
          </Accordion.Trigger>
          <Accordion.Content value="size">
            사이즈 불만족 시 무료 교환 서비스를 제공합니다. 상품 페이지의 사이즈
            가이드를 꼭 확인해주세요.
          </Accordion.Content>
        </Accordion.Item>
      </Accordion>
    </div>
  );
}

React.Children을 활용한 대안

이 방식은 Context 없이도 상태 공유가 가능합니다. cloneElement를 사용해 자식 컴포넌트에 상태와 제어 함수를 직접 주입합니다. 다만 구조가 제한적입니다.

function Accordion({ children, defaultValue = null }) {
  const [activeItem, setActiveItem] = useState(defaultValue);

  const toggleItem = (value) => {
    setActiveItem(activeItem === value ? null : value);
  };

  return (
    <div className="accordion">
      {React.Children.map(children, (child) =>
        React.cloneElement(child, { activeItem, toggleItem })
      )}
    </div>
  );
}

AccordionItem은 자식 컴포넌트에 필요한 prop을 전달하며 계층을 유지합니다.

function AccordionItem({ children, value, activeItem, toggleItem }) {
  return (
    <div className="accordion-item">
      {React.Children.map(children, (child) =>
        React.cloneElement(child, {
          value,
          activeItem,
          toggleItem,
          isActive: activeItem === value,
        })
      )}
    </div>
  );
}

⚠️ 주의사항

1. React.Children 사용 시 구조 제한

// ❌ 작동하지 않음
<Accordion>
  <div>
    <Accordion.Item value="test">
      <Accordion.Trigger value="test">질문</Accordion.Trigger>
    </Accordion.Item>
  </div>
</Accordion>

// ✅ 작동함
<Accordion>
  <Accordion.Item value="test">
    <Accordion.Trigger value="test">질문</Accordion.Trigger>
  </Accordion.Item>
</Accordion>

2. Props 충돌 주의

cloneElement로 props를 주입할 때 기존 props와 충돌하지 않도록 주의해야 합니다.

3. TypeScript 통합 시 복잡성

컴파운드 패턴은 타입 추론이 어려워지기 때문에 별도의 타입 관리 전략이 필요합니다.

  • 디버깅을 쉽게 하기 위해 displayName 설정:
AccordionItem.displayName = "Accordion.Item";
AccordionTrigger.displayName = "Accordion.Trigger";
AccordionContent.displayName = "Accordion.Content";
  • PropTypes 또는 TypeScript로 prop 검증 추가:
Accordion.propTypes = {
  defaultValue: PropTypes.string,
  children: PropTypes.node.isRequired,
};

마무리

이번 글에서는 Context API를 이용한 Accordion UI 컴포넌트 예제를 통해 이 패턴의 구조와 동작 방식을 살펴보았습니다. UI 라이브러리를 만들거나 재사용 가능한 컴포넌트를 설계할 때 유용하게 활용할 수 있는 방법이니, 직접 구현해 보며 익혀 보시길 추천드립니다.